In this second version, we add the following features:
We track when a book was checked out and when it is due
We calculate fines when returning books
We associate fines with patrons
We allow the patron to pay for their fines
We disallow patrons from checking out books when they have fines
Along the way, we make a lot of additions and changes. Based on the updated LibraryTest, here is a list of all the changes I made to get things to work (note if you choose to start from the test and make things work yourself, you results may vary): src/entity
Book
Now has an optional Loan object instead of a direct reference to a Patron.
Fine
New class, represents an individual fine generated from returning one book late. A Patron has zero to many of these.
Loan
New class, represents the information related to the relationship between Patron and Book. A Patron has a One to Many relationship with Loan while a book as a One to One that is optional (can be null).
LoanId
A key-class for the Loan class. The key is two columns, a foreign key to Patron and a foreign key to Book.
Patron
Now has a One to Many relationship with both Loan and Fines. It also has several new methods in support of those new/changed attributes.
src/exception
BookNotCheckedOut
New exception class. Thrown when trying to return a book that is not checked out.
InsufficientFunds
New exception class. Thrown when Patron tries to pay fines but does not tender enough cash.
PatronHasFines
New exception class. Thrown when Patron tries to check out a book but already has fines.
src/session
Library
Substantially changed in support of the new requirements.
LoanDao
New class. Provides some simple query support directly related to loan class.
src/util
DateTimeUtil
A new class. Provides some basic date/time utilities.
test/session
LibraryTest
Several new tests in support of new functionality.
test/util
DateTimeUtilTest
Several test in support of new utility class.
New Utility
To calculate fines, we needed to determine the number of days late a Patron returned a Book. Here are the tests for that class:
DateTimeUtilTest.java
packageutil;importstaticorg.junit.Assert.assertEquals;importjava.util.Calendar;importjava.util.Date;importorg.junit.Test;/**
* A class to test the DateTimeUtil class. Verifies that the calculation for the
* number of days between to dates is correct for several different scenarios.
*/publicclass DateTimeUtilTest {publicstaticfinalDate DATE = Calendar.getInstance().getTime();
@Test
publicvoid dateBetween0(){
assertEquals(0, DateTimeUtil.daysBetween(DATE, DATE));}
@Test
publicvoid dateBetween1(){
assertEquals(1, DateTimeUtil.daysBetween(DATE, addDaysToDate(DATE, 1)));}
@Test
publicvoid dateBetweenMinus1(){
assertEquals(-1, DateTimeUtil
.daysBetween(DATE, addDaysToDate(DATE, -1)));}
@Test
publicvoid startInDstEndOutOfDst(){finalDate inDst = createDate(2006, 9, 1);finalDate outDst = createDate(2006, 10, 1);
assertEquals(31, DateTimeUtil.daysBetween(inDst, outDst));}
@Test
publicvoid startOutDstEndInDst(){finalDate inDst = createDate(2006, 9, 1);finalDate outDst = createDate(2006, 10, 1);
assertEquals(-31, DateTimeUtil.daysBetween(outDst, inDst));}
@Test
publicvoid overLeapDayNoChangeInDst(){finalDate beforeLeapDay = createDate(2004, 1, 27);finalDate afterLeapDay = createDate(2004, 2, 1);
assertEquals(3, DateTimeUtil.daysBetween(beforeLeapDay, afterLeapDay));}
@Test
publicvoid overLeapDayAndOverDstChange(){finalDate beforeLeapDayNonDst = createDate(2004, 1, 27);finalDate afterLeapDayAndDst = createDate(2004, 3, 5);
assertEquals(38, DateTimeUtil.daysBetween(beforeLeapDayNonDst,
afterLeapDayAndDst));}privateDate addDaysToDate(finalDate date, finalint days){Calendar c = Calendar.getInstance();
c.setTime(date);
c.add(Calendar.DAY_OF_YEAR, days);return c.getTime();}privateDate createDate(finalint year, finalint month, finalint day){finalCalendar c = Calendar.getInstance();
c.set(Calendar.YEAR, year);
c.set(Calendar.MONTH, month);
c.set(Calendar.DAY_OF_MONTH, day);return c.getTime();}}
DateTimeUtil
packageutil;importjava.util.Calendar;importjava.util.Date;importjava.util.GregorianCalendar;/**
* This is a simple class containing date/time utilities to avoid proliferation
* of duplicate code through the system.
*/publicclass DateTimeUtil {privatestaticfinalint MS_IN_HOUR = 1000*60*60;privatestaticfinalint MS_IN_Day = 24* MS_IN_HOUR;/**
* This is a class with all static methods (often called a utility class).
* To document the fact that it should be used without first being
* instantiated, we make the constructor private. Furthermore, some code
* evaluation tools, such as PMD, will complain about an empty method body,
* so we add a comment in the method body to appease such tools.
*
*/private DateTimeUtil(){// I'm a utility class, do not instantiate me}/**
* Remove all of the time elements from a date.
*/publicstaticvoid removeTimeFrom(finalCalendar c){
c.clear(Calendar.AM_PM);
c.clear(Calendar.HOUR_OF_DAY);
c.clear(Calendar.HOUR);
c.clear(Calendar.MINUTE);
c.clear(Calendar.SECOND);
c.clear(Calendar.MILLISECOND);}/**
* This is a simple algorithm to calculate the number of days between two
* dates. It is not very accurate, does not take into consideration leap
* years, etc. Do not use this in production code. It serves our purposes
* here.
*
* @param d1
* "from date"
* @param d2
* "to date"
*
* @return number of times "midnight" is crossed between these two dates,
* logically this is d2 - d1.
*/publicstaticint daysBetween(finalDate d1, finalDate d2){GregorianCalendar c1 = newGregorianCalendar();
c1.setTime(d1);GregorianCalendar c2 = newGregorianCalendar();
c2.setTime(d2);finallong t1 = c1.getTimeInMillis();finallong t2 = c2.getTimeInMillis();long diff = t2 - t1;finalboolean startInDst = c1.getTimeZone().inDaylightTime(d1);finalboolean endInDst = c2.getTimeZone().inDaylightTime(d2);if(startInDst &&!endInDst){
diff -= MS_IN_HOUR;}if(!startInDst && endInDst){
diff += MS_IN_HOUR;}return(int)(diff / MS_IN_Day);}}
The Exceptions
Here are the three new exception classes: BookNotCheckedOut
packageexception;/**
* A simple unchecked exception reflecting a particular business rule violation.
* A book cannot be checked out if it is already checked out.
*
* This exception inherits from RuntimeException (or it is an unchecked
* exception). Why? The policy of whether to use checked or unchecked exceptions
* is project dependent. We are using this for learning about EJB3 and JPA and
* NOT about how to write exceptions, so using one policy versus the other is
* arbitrary for our purposes. Working with unchecked exceptions is a bit looser
* but also keeps the code looking a bit cleaner, so we've gone with unchecked
* exceptions.
*/publicclass BookNotCheckedOut extendsRuntimeException{privatestaticfinallong serialVersionUID = 2286908621531520488L;finalLong bookId;public BookNotCheckedOut(finalLong bookId){this.bookId = bookId;}publicLong getBookId(){return bookId;}}
InsufficientFunds.java
packageexception;/**
* Thrown when a Patron attempts to pay less that then total fines owed.
*/publicclass InsufficientFunds extendsRuntimeException{privatestaticfinallong serialVersionUID = -735261730912439200L;}
PatronHasFines.java
packageexception;/**
* Thrown when Patron attempts to checkout a book but has fines.
*/publicclass PatronHasFines extendsRuntimeException{privatestaticfinallong serialVersionUID = 2868510410691634148L;double totalFines;public PatronHasFines(finaldouble amount){this.totalFines = amount;}publicdouble getTotalFines(){return totalFines;}}
The Library Test
Many of the original tests are different from the previous version. Additionally, there are many new tests. Here is the test. Once you get this in to your system, you might want to simply get all of the tests methods to compile and then get the tests to pass.
Doing so is approaching formal TDD. It is different in a few important respects:
You are given the tests rather than writing them yourself
You are working on many tests at once rather than just one (or a very few) at a time
Even so, this suite of test fully express the new set of requirements for version 2.
packagesession;importstaticorg.junit.Assert.assertEquals;importstaticorg.junit.Assert.assertFalse;importstaticorg.junit.Assert.assertNotNull;importstaticorg.junit.Assert.assertTrue;importstaticorg.junit.Assert.fail;importjava.util.Calendar;importjava.util.Date;importjava.util.List;importjava.util.Set;importjavax.persistence.EntityNotFoundException;importorg.junit.Before;importorg.junit.BeforeClass;importorg.junit.Test;importutil.DateTimeUtil;importentity.Address;importentity.Author;importentity.Book;importentity.Name;importentity.Patron;importexception.BookAlreadyCheckedOut;importexception.BookNotCheckedOut;importexception.InsufficientFunds;importexception.PatronHasFines;publicclass LibraryTest extends EntityManagerBasedTest {privatestaticfinallong ID_DNE = -443123222l;privatestaticfinalString PATRON_ID = "113322";privatestaticfinalString ISBN = "1-932394-15-X";privatestaticDate CURRENT_DATE;privatestaticDate CURRENT_PLUS_8;privatestaticDate CURRENT_PLUS_14;privatestaticDate CURRENT_PLUS_15;private Library library;
@Before
publicvoid setupLibrary(){final BookDao bd = new BookDao();
bd.setEm(getEm());final PatronDao pd = new PatronDao();
pd.setEm(getEm());final LoanDao ld = new LoanDao();
ld.setEm(getEm());
library = new Library();
library.setBookDao(bd);
library.setPatronDao(pd);
library.setLoanDao(ld);}
@BeforeClass
publicstaticvoid setupDates(){Calendar c = Calendar.getInstance();
DateTimeUtil.removeTimeFrom(c);
CURRENT_DATE = c.getTime();
c.add(Calendar.DAY_OF_MONTH, 8);
CURRENT_PLUS_8 = c.getTime();
c.add(Calendar.DAY_OF_MONTH, 6);
CURRENT_PLUS_14 = c.getTime();
c.add(Calendar.DAY_OF_MONTH, 1);
CURRENT_PLUS_15 = c.getTime();}
@Test
publicvoid addBook(){finalBook b = createBook();Set<Author> authors = b.getAuthors();finalBook found = library.findBookById(b.getId());
assertTrue(found.getAuthors().containsAll(authors));}
@Test(expected = EntityNotFoundException.class)publicvoid lookupBookThatDoesNotExist(){
library.findBookById(ID_DNE);}
@Test
publicvoid addPatron(){final Patron p = createPatron();final Patron found = library.findPatronById(p.getId());
assertNotNull(found);}
@Test(expected = EntityNotFoundException.class)publicvoid lookupPatronThatDoesNotExist(){
library.findPatronById(ID_DNE);}
@Test
publicvoid checkoutBook(){finalBook b1 = createBook();finalBook b2 = createBook();final Patron p = createPatron();
library.checkout(p.getId(), CURRENT_DATE, b1.getId(), b2.getId());finalList<Book> list = library.listBooksOnLoanTo(p.getId());
assertEquals(2, list.size());for(Book b : list){
assertTrue(b.isOnLoanTo(p));
assertTrue(b.dueDateEquals(CURRENT_PLUS_14));}}
@Test
publicvoid returnBook(){finalBook b = createBook();final Patron p = createPatron();
library.checkout(p.getId(), CURRENT_DATE, b.getId());finalint booksBefore = p.getCheckedOutResources().size();
assertTrue(b.isCheckedOut());
library.returnBook(CURRENT_PLUS_8, b.getId());
assertEquals(booksBefore - 1, p.getCheckedOutResources().size());
assertFalse(b.isCheckedOut());
assertEquals(0, p.getFines().size());}
@Test
publicvoid returnBookLate(){finalBook b = createBook();final Patron p = createPatron();
library.checkout(p.getId(), CURRENT_DATE, b.getId());
library.returnBook(CURRENT_PLUS_15, b.getId());
assertEquals(1, p.getFines().size());
assertEquals(.25, p.calculateTotalFines());}
@Test(expected = BookNotCheckedOut.class)publicvoid returnBookThatsNotCheckedOut(){finalBook b = createBook();
assertFalse(b.isCheckedOut());
library.returnBook(CURRENT_PLUS_8, b.getId());}
@Test(expected = BookAlreadyCheckedOut.class)publicvoid checkoutBookThatIsAlreadyCheckedOut(){finalBook b = createBook();final Patron p1 = createPatron();final Patron p2 = createPatron();
library.checkout(p1.getId(), CURRENT_DATE, b.getId());
library.checkout(p2.getId(), CURRENT_DATE, b.getId());}
@Test(expected = EntityNotFoundException.class)publicvoid checkoutBookThatDoesNotExist(){final Patron p = createPatron();
library.checkout(p.getId(), CURRENT_DATE, ID_DNE);}
@Test(expected = EntityNotFoundException.class)publicvoid checkoutBookToPatronThatDoesNotExist(){finalBook b = createBook();
library.checkout(ID_DNE, CURRENT_DATE, b.getId());}
@Test
publicvoid findOverdueBooks(){final Patron p = createPatron();finalBook b1 = createBook();finalBook b2 = createBook();
library.checkout(p.getId(), CURRENT_DATE, b1.getId());
library.checkout(p.getId(), CURRENT_PLUS_8, b2.getId());finalList<Book> notOverdue = library
.findAllOverdueBooks(CURRENT_PLUS_8);
assertEquals(0, notOverdue.size());finalList<Book> overdue = library.findAllOverdueBooks(CURRENT_PLUS_15);
assertEquals(1, overdue.size());
assertTrue(overdue.contains(b1));}
@Test
publicvoid patronsWithOverdueBooks(){final Patron p = createPatron();finalBook b1 = createBook();finalBook b2 = createBook();
library.checkout(p.getId(), CURRENT_DATE, b1.getId());
library.checkout(p.getId(), CURRENT_PLUS_8, b2.getId());finalList<Patron> noPatrons = library
.findAllPatronsWithOverdueBooks(CURRENT_PLUS_14);
assertEquals(0, noPatrons.size());finalList<Patron> onePatron = library
.findAllPatronsWithOverdueBooks(CURRENT_PLUS_15);
assertEquals(1, onePatron.size());}
@Test
publicvoid calculateTotalFinesForPatron(){final Patron p = createPatron();finalBook b1 = createBook();finalBook b2 = createBook();
library.checkout(p.getId(), CURRENT_DATE, b1.getId());
library.checkout(p.getId(), CURRENT_DATE, b2.getId());
library.returnBook(CURRENT_PLUS_15, b1.getId(), b2.getId());
assertEquals(.5, library.calculateTotalFinesFor(p.getId()));}
@Test
publicvoid payFineExactAmount(){final Patron p = createPatron();finalBook b1 = createBook();
library.checkout(p.getId(), CURRENT_DATE, b1.getId());
library.returnBook(CURRENT_PLUS_15, b1.getId());double change = library.tenderFine(p.getId(), .25);
assertEquals(0d, change);
assertEquals(0, p.getFines().size());}
@Test(expected = InsufficientFunds.class)publicvoid payFineInsufficientFunds(){final Patron p = createPatron();finalBook b1 = createBook();
library.checkout(p.getId(), CURRENT_DATE, b1.getId());
library.returnBook(CURRENT_PLUS_15, b1.getId());
library.tenderFine(p.getId(), .20);}/**
* This is an example of a test where we expect an exception. However,
* unlike other tests where we use expected=ExceptionClass.class, we need to
* catch the exception because we are additionally verifying a value in the
* thrown exception. This test is written how you'd write a test expecting
* an exception prior to JUnit 4.
*/
@Test
publicvoid patronCannotCheckoutWithFines(){final Patron p = createPatron();finalBook b1 = createBook();
library.checkout(p.getId(), CURRENT_DATE, b1.getId());
library.returnBook(CURRENT_PLUS_15, b1.getId());finalBook b2 = createBook();try{
library.checkout(p.getId(), CURRENT_DATE, b2.getId());
fail(String.format("Should have thrown exception: %s",
PatronHasFines.class.getName()));}catch(PatronHasFines e){
assertEquals(.25, e.getTotalFines());}}privateBook createBook(){final Author a1 = new Author(newName("Christian", "Bauer"));final Author a2 = new Author(newName("Gavin", "King"));return library.createBook("Hibernate In Action", ISBN, Calendar
.getInstance().getTime(), a1, a2);}private Patron createPatron(){final Address a = new Address("5080 Spectrum Drive", "", "Dallas",
"TX", "75001");return library.createPatron(PATRON_ID, "Brett", "Schuchert",
"555-1212", a);}}
Along the way, we make a lot of additions and changes. Based on the updated LibraryTest, here is a list of all the changes I made to get things to work (note if you choose to start from the test and make things work yourself, you results may vary):
src/entity
src/exception
src/session
src/util
test/session
test/util
New Utility
To calculate fines, we needed to determine the number of days late a Patron returned a Book. Here are the tests for that class:DateTimeUtilTest.java
DateTimeUtil
The Exceptions
Here are the three new exception classes:BookNotCheckedOut
InsufficientFunds.java
PatronHasFines.java
The Library Test
Many of the original tests are different from the previous version. Additionally, there are many new tests. Here is the test. Once you get this in to your system, you might want to simply get all of the tests methods to compile and then get the tests to pass.Doing so is approaching formal TDD. It is different in a few important respects:
Even so, this suite of test fully express the new set of requirements for version 2.